#!/usr/bin/env python3

import argparse
import os
import shlex
import subprocess
import sys

args = sys.argv[1:]

parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("tests", nargs="*", help="List of tests to run. Empty to run all tests. Each test corresponds to one C file.")
parser.add_argument("--vcd", action="store_true", help="Pass --vcd flag to simulator, to generate waveform dumps.")
parser.add_argument("--tb", default="../tb_cxxrtl/tb", help="Pass tb executable to run tests.")
parser.add_argument("--tbarg", action="append", default=[], help="Extra argument to pass to tb executable. Can pass --tbarg=xxx multiple times to pass multiple arguments.")
parser.add_argument("--postcmd", action="append", default=[], help="Add a command to run post-simulation, e.g. log file processing. The string TEST is expanded to the test result file name, minus any file extensions.")
parser.epilog = """
Example command lines:

Run all tests:
./runtests

Just run hello world, and generate waves:
./runtests hello_world --vcd

(The last run test is always softlinked as "waves.vcd". You can view it in
gtkwave using the default wave config, "waves.gtkw".)

Run under rvcpp, enable instruction tracing, and post-process log using disassembly:
./runtests --tb ../rvcpp/rvcpp --tbarg=--trace --postcmd="../rvcpp/scripts/annotate_trace.py TEST.log TEST_annotated.log -d TEST.dis"
"""
args = parser.parse_args()

testlist = args.tests

if len(testlist) > 0:
	# This happens a lot when autocomplete is used:
	for i, n in enumerate(testlist):
		if n.endswith(".c"):
			testlist[i] = n[:-2]
else:
	testlist = []
	for path in os.listdir():
		if os.path.isfile(path) and path.endswith(".c"):
			testlist.append(path[:-2])

testlist = sorted(testlist)

tb_dir = os.path.join(*os.path.split(os.path.abspath(args.tb))[:-1])
tb_build_ret = subprocess.run(
	["make", "-C", tb_dir, "all"],
	timeout=300
)
if tb_build_ret.returncode != 0:
	sys.exit("Failed.")

all_passed = True

passed_test_count = 0
for test in testlist:
	sys.stdout.write(f"{test:<30}")
	failed = False
	test_build_ret = subprocess.run(
		["make", f"APP={test}", f"tmp/{test}.bin"],
		stdout=subprocess.DEVNULL
	)
	if test_build_ret.returncode != 0:
		print("\033[33m[MK ERR]\033[39m")
		failed = True

	if not failed:
		cmdline = [args.tb, "--bin", f"tmp/{test}.bin", "--cycles", "1000000"]
		if args.vcd:
			cmdline += ["--vcd", f"tmp/{test}.vcd"]
		cmdline += args.tbarg

		try:
			test_run_ret = subprocess.run(
				cmdline,
				stdout = subprocess.PIPE,
				stderr = subprocess.PIPE,
				timeout=10
			)
			with open(f"tmp/{test}.log", "wb") as f:
				f.write(test_run_ret.stdout)
		except subprocess.TimeoutExpired:
			print("\033[31m[TIMOUT]\033[39m")
			failed = True

	# Testbench itself should always exit successfully.
	if not failed:
		if test_run_ret.returncode != 0:
			print("Negative return code from testbench!")
			failed = True

	# Link last-run test as "waves.vcd" for use with generic .gtkw wave config
	if args.vcd:
		try:
			os.system(f"ln -sf tmp/{test}.vcd waves.vcd")
		except:
			# Don't care, not a critical part of the test run
			pass

	# Pass if the program under test has zero exit code AND its output matches
	# the expected output (if there is an expected_output file)

	if not failed:
		output_lines = test_run_ret.stdout.decode("utf-8").strip().splitlines()
		returncode = -1
		if len(output_lines) >= 2:
			exit_line = output_lines[-2]
			if exit_line.startswith("CPU requested halt"):
				try:
					returncode = int(exit_line.split(" ")[-1])
				except:
					pass
		if returncode != 0:
			print("\033[31m[BADRET]\033[39m")
			failed = True

	if not failed:
		test_src = open(f"{test}.c").read()
		if "/*EXPECTED-OUTPUT" in test_src:
			good_output = True
			try:
				expected_start = test_src.find("/*EXPECTED-OUTPUT")
				expected_end = test_src.find("*/", expected_start)
				expected_lines = test_src[expected_start:expected_end + 1].splitlines()[1:-1]
				while expected_lines[0].strip() == "":
					del expected_lines[0]
				while expected_lines[-1].strip() == "":
					del expected_lines[-1]

				# Allow single-line comments within the expected output, in case some of
				# the output needs explanation inline in the test source. If the line is
				# empty after stripping comments, still don't remove the line.
				for i, l in enumerate(expected_lines):
					if "//" in l:
						expected_lines[i] = l.split("//")[0].rstrip()

				# Drop last two lines, which should just be tb output (checked in BADRET)
				output_lines = output_lines[:-2]
				while output_lines[0].strip() == "":
					del output_lines[0]
				while output_lines[-1].strip() == "":
					del output_lines[-1]

				if expected_lines != output_lines:
					good_output = False
			except:
				good_output = False
			if not good_output:
				print("\033[31m[BADOUT]\033[39m")
				failed = True

	if not failed:
		print("\033[32m[PASSED]\033[39m")
		passed_test_count += 1

	# Post-processing commands are run regardless of success. Their return
	# codes are ignored.
	for postcmd in args.postcmd:
		postcmd = postcmd.replace("TEST", f"tmp/{test}")
		subprocess.run(shlex.split(postcmd))

print(f"\nPassed: {passed_test_count} out of {len(testlist)}")

sys.exit(not all_passed)


